今天再來講多一點 flatMap
的例子吧!首先從 List 開始!
一樣先看例子,以下這個例子的目標是分解句子中的單字,其中分解的規則使用是單字跟單字之間的空白。另外,順便幫大家複習一下 lambda as function。
// 1
val sentences: List<String> = listOf("Hello world!",
"Awesome functional kotlin!",
"I like pie I like cake")
// 2
val splitFun: (String) -> List<String> = { sentence: String -> sentence.split(" ")}
// 3
val result: List<String> = sentences.map(splitFun)
.flatten()
println(result)
// [Hello, world!, Awesome, functional, kotlin!, I, like, pie, I, like, cake]
sentences
是一些英文例句,其中有著許多空白split(" ")
來實作。map
這個 operator。之後,再利用 flatten
將二維的 List 降為一維。如果沒有 flatten
結果將會是 [[Hello, world!], [Awesome, functional, kotlin!], [I, like, pie, I, like, cake]]
,而這個不是我們想要的結果,拿一維的資料來做分析才會方便。
對了,還記得上一篇的 flatMap
嗎?來試看看結果會不會一樣吧!
val result = sentences.flatMap(splitFun)
println(result)
// [Hello, world!, Awesome, functional, kotlin!, I, like, pie, I, like, cake]
結果一樣!而且行數還更少了!現在再來進一步分析,在 map
中放進不同的 function ,並且外層再用一個 lambda 包起來,觀察輸入以及輸出型別的變化。
val splitFun: (String) -> List<String> = { sentence: String -> sentence.split(" ")}
// 1
val composeFun1: (List<String>) -> List<String> = { sentences: List<String> ->
sentences.map{ "$it by Yanbin" }
}
// 2
val composeFun2: (List<String>) -> List<List<String>> = { sentences: List<String> ->
sentences.map(splitFun)
}
// 3
val composeFun3: (List<String>) -> List<String> = { sentences: List<String> ->
sentences.flatMap(splitFun)
}
map
,原本輸入的“容器”是 List,經過計算過後,由於執行的 function 是 {"$it by Yanbin"}
,型別沒有任何變化,所以輸出的"容器"也還是 List。map
裡的 function 本身也會產出一個“容器”的話,在這裡是 splitFun: String -> List<String>
,回傳值就是兩層的“容器”了。flatMap
就可以讓輸出變回一層的“容器”。然後 composeFun3 的 function type 就會跟 compose1 的 function type 一樣是 (List → String)。既然 Observable 跟 List 都有 flatMap
,他們都是一種“容器”。之前也有說過,Try 也是一種“容器”,那 Try 也會有 flatMap
嗎?如果有,那又是什麼時候才會用到呢?
容許我偷懶一下,重用前幾篇看過的一個例子:
class AccountRepo() {
// 從 id 找不到的話會丟 Exception
fun queryFromId(id: String): Account {...}
// account 必須還在,有可能被其他人不小心刪掉
fun updateAccount(account: Account): Boolean {...}
}
class BankService() {
// 帳戶的錢必需還要夠多,不夠的話會丟 Exception
fun withDraw(account: Account, amount: Int): Account {...}
}
在這個範例中,有很多會發生錯誤的狀態,而 Try 之前有介紹過,可以使用 Try.Fail() 來包裝所有的錯誤狀態,與其任由 Exception 隨意丟出來,用 Try 來包裝不是很好嗎?所以在這裏 AccountRepo
跟 BankService
function 的 return type 全部都改用 Try 來做包裝,這麼做有另一個好處,這些函式的意圖也更加清楚了,使用方( AccountRepo
的呼叫者)不得不意識到錯誤有可能會發生。
class AccountRepo() {
fun queryFromId(id: String): Try<Account> {
return try {
...
} catch(error: Throwable) {
Try.Fail(error)
}
}
fun updateAccount(account: Account): Try<Boolean> {
return try {
...
} catch(error: Throwable) {
Try.Fail(error)
}
}
}
class BankService() {
fun withDraw(account: Account, amount: Int): Try<Account> {
return try {
...
} catch(error: Throwable) {
Try.Fail(error)
}
}
}
// 相信大家都覺得 try catch 寫三次很煩,在 Arrow 中其實有一個替代方案:
// 使用 extension function - Try
// ref: https://arrow-kt.io/docs/0.10/apidocs/arrow-core-data/arrow.core/-try/
好的,一切準備就緒,現在試試看把所有東西都組合起來吧!這邊有一個前提,我們暫時忽略非同步的所有狀況,先假設一切操作都是同步的。了解完前提之後,再來複習一下要完成的需求吧,這需求總共有三個步驟:尋找帳戶、提款、更新帳戶。
fun withDrawMoney(accountId: String, amount: Int) {
accountRepo.queryFromId(accountId)
.??? { account: Account -> bankService.withDraw(account, amount) }
.??? { account: Account -> accountRepo.updateAccount(account) }
.fold(success = {...}, fail = {...})
}
在 withDrawMoney
這個函式中,可以很清楚的看到這三個步驟依序執行,最後再使用 fold
來處理結果,那中間的 operator 要填什麼呢?首先,第一個步驟回傳結果的型別是 Try ,第二個步驟 bankService.withDraw
的型別也是使用 Try 這個容器,如果直接用 map 的話,理所當然的可以想像出結果會是一個兩層容器:Try<Try<...>>。所以...大家應該想到了,沒錯!在這裡應該也要用 flatMap
!
fun withDrawMoney(accountId: String, amount: Int) {
accountRepo.queryFromId(accountId)
.flatMap { account: Account -> bankService.withDraw(account, amount) }
.flatMap { account: Account -> accountRepo.updateAccount(account) }
.fold(success = {...}, fail = {...})
}
sealed class Try<T>{
...
fun <R> flatMap(transform: (T) -> Try<R>): Try<R> {
return when(this) {
is Success -> transform(this.value)
is Fail -> Fail<R>(this.error)
}
}
}
其實 flatMap
的實作非常簡單,最難的部分就是 function 的 input 型別跟 output 型別。首先,回傳的容器一定是 Try ,這無庸置疑。然而,我們要保留一點彈性,所以讓使用者自行決定回傳的型別,也就是 R
。好,完成 output 的部分了,那 input 呢?我們要接收的是一個 function ,而且從原本的型別 T
轉換成型別 R
,但是由於這個型別 R
同時被一個容器給包起來了,所以我們才需要 flatten
,於是就導出來了,input 的 function type 應該是 (T) → Try。那實作內容呢?可以分兩個 case ,第一個是失敗,既然目前已經失敗了,那我們也不需要浪費時間去做計算,繼續將失敗的內容傳遞下去即可。第二個是成功,成功的話,那直接執行 transform 這個 function 不就好了嗎?執行的結果還剛好是 Try ,什麼都不用動。
我們已經介紹過 Observable, List, Try 各自的 flatMap
了,如果把他放在一起可以觀察到一個有趣的現象:
// Observable (Java)
public final <R> Observable<R> flatMap(Function<? super T, ? extends ObservableSource<? extends R>> mapper) {
return flatMap(mapper, false);
}
// List
public inline fun <T, R> Iterable<T>.flatMap(transform: (T) -> Iterable<R>): List<R> {
return flatMapTo(ArrayList<R>(), transform)
}
// Try
fun <R> flatMap(transform: (T) -> Try<R>): Try<R> = {...}
全部都長的很像!輸入一樣都是一個從 T
到同一個容器 R
的 transform function,回傳的型別一樣都是同一個容器的 R
,所以對於任何一個 flatMap
我們都可以這樣寫:
fun <R> flatMap(transform: (T) -> F<R>): F<R> = {...}
上面的 F
可以替換成任何容器。現在,我們發現了重複的概念出現三次了, 根據 DRY (Don't Repeat Yourself) 原則,這些是不是可以共同抽取到某個抽象呢?答案是可以的,functional programming 最厲害的地方就是對這些概念進行抽象化,讓一切都以存粹的數學來表示,而這背後最基礎的數學理論 - Category theory,將會在下一篇介紹給大家。